深入理解 ThreadLocal

ThreadLocal 的内存泄露?什么原因?如何避免?-知乎

看了一篇文章,发现最后的结论有问题,没必要static,而且static的ThreadLocal变量在ThreadLocalMap对应的Entry的key永远不会是null,根本就不会被回收。而且强引用的ThreadLocal造成的oom本身就不叫内存泄漏,而是内存溢出。

在网上又搜了很多文章都不满意,于是自己整理了一下,源码来自 JDK8:

假设对象 obj 中存在 ThreadLocal 类型的成员变量,该成员变量的对象为(tl)。之后程序中,对象 obj 在某容器中(如 spring)一直被强引用持有,并在线程 a、b (t-a, t-b)所对应的 ThreadLocalMap (tlm-a, tlm-b)中保存了两个值的副本(v-a, v-b)

假设 tl 在 tml-a 中的 ThreadLocalMap.Entry 对象为 tml.e-a,tl 在 tml-b 中的 Entry 为 tml.e-b:

1
2
3
4
5
6
7
static class Entry extends WeakReference<ThreadLocal<?>> { 
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}

Entry 的 key 弱引用了 tl,获取 key 的方法即 WeakReference#get() ,其 value 为强引用。

此时,持有 tl 引用的地方有三处:一处容器的强引用,两处 Entry (tlm.e-a 与 tml.e-b)的 key 的弱引用

持有 tl 对应两个副本的强引用都只有一处:tml.e-a 的 value 强引用副本 v-a,tml.e-b 的 value 强引用副本 v-b

线程 a 调用 tl.get() 时,通过 currentThread -> tlm-a -> tlm.e-a -> v-a 获取到的 v-a

值得一提的是,FastThreadLocal 正是通过改造 ThreadLocalMap 中获取 ThreadLoacl 对象对应 Entry 的速度,加速了 tlm-a -> tlm.e-a 环节,其原理是给每个 ThreadLocal 申请了一个唯一 id (InternalThreadLocal#nextVariableIndex 方法获得),这样该对象在 ThreadLocalMap 对应的 entry 就可以通过 id 映射到数组下标,O(1) 访达。

当容器对 obj 的强引用被释放后,在下一次 GC 时,obj 会被清理掉, tl 此时只在 tlm.e-a 与 tml.e-b 存在两个弱引用,也会被回收掉。此时 tml.e-a 与 tml.e-b 分别在 tml-a 与 tml-b 中存在强引用,这两个对象获取 key 的 get() 方法( WeakReference#get() )返回值都是 null,并且分别持有 v-a 、v-b 的强引用。

下面重点来讲 jdk 的 ThreadLocalMap 如何清理 key 为 null 的 Entry。ThreadLocalMap 中的元素通过【环形数组】 + 【hash 对应 slot】 维护读写方法,set 时如果 slot 冲突则向后顺延。

  1. 在 ThreadLocalMap 中通过 ThreadLocal 来 get entry 时,从 hash 取模后对应的数组 slot 获得 entry。如果 entry 的 key 与 tl 不同,则尝试取后一个元素,直到取到 key 与 tl 一致的 Entry。如果直到下一个 null 位置都不存在 key == tl 的 Entry,返回 null。其中跳过的每个 Entry 有 2 种情况:
    1. entry 存在且 key != tl ,说明是另一个同 hash 或者 hash 正好在该位置的 entry,直接跳过;
    2. entry 存在但 key 为 null,会调用 expungeStaleEntry 方法来删除该位置的无效 entry
  2. 在 tlm-a 中 set entry (tl, v-a)时,由于保存 entry 的数组初始长度为 16,并且 set 后会进行 rehash()->resize() 检查,set 调用开始时的数组一定有空余位置,直接计算 tl 对象的 hashCode() 与数组长度取模后的值作为目标下标。当对应 slot 为 null 时,则可以直接 set 进去;否则检查从对应位置向后顺延,放在第一个为空的位置上。其中如果存在 k 为空的 entry,会进行清理并检查后续位置的节点是否需要前移填充过来(后续节点中的无效节点也会处理掉)
  3. ThreadLocalMap remove 一个 ThreadLocal 对应的 Entry 时,会将 remove 位置之后,直到下一个 null 位置的数组元素重新 hash 到对应位置,所以 hash 取模后对应 slot 相同的 entry 在数组之间一定不会存在 null ,但可能存在 hash 取模后不同的其他 entry。

可以看到,从 ThreadLocalMap 通过 ThreadLocal 获取 entry 时,如果哈希碰撞 & 向后顺延时取到 key 为 null 的 entry,会顺手把这个无效的 entry 清理掉。

另外 ThreadLocalMap 每次 set(ThreadLocal key, T value) 成功后会调用:

1
2
if (!cleanSomeSlots(i, sz) && sz >= threshold)    
rehash();

其中 cleanSomeSlots 就是检查了数组长度取对数( do {…} while ( len >>> 1 != 0 ) )的槽点数量的 entry,如果 key 为空就移除掉。处理 key 为空 entry 的方法就是 expungeStaleEntry(slot),它会在以下时机被调用:

  1. getEntry 时如果发现目标 slot 的 entry 的 key != tl ,会遍历取 key == tl 的 entry 直到下一个 null 位置,其中 key 为空的 entry 会被擦除
  2. remove 时先 get 到目标 entry 再 remove,其中擦除时机同上
  3. set 时如果哈希碰撞的节点之后,直到下一个 null 节点之前,存在无效 entry 则会被擦除。同时会扫描清除碰撞节点之前到上一个 null 节点之间的 entry
  4. set 后的 cleanSomeSlots 方法里
  5. rehash 时全部扫描 clean 一遍

网上经常出现的一种说法是,线程在线程池里时,ThreadLocalMap 与 Thread 同生命周期不会被回收,其中无效的 Entry 可能会一直残留。但这发生在该线程的 ThreadLocalMap 都不再使用的情况。在实际应用中,不论数据库连接池还是其他 dubbo 服务,都是用了 ThreadLocal 技术,在看不见的地方暗戳戳地调用了 n 次 get/set/remove 方法,每个线程都被洗刷地干干净净。如果是自己创建的线程池,引用 ThreadLocal 对象的实例时 spring bean,那么其本身就是被强引用的单例,也谈不上内存泄漏,只是有可能被滞后一段时间才 GC

除使 ThreadLocal 管理了某种带集合的对象,然后每次线程忘了 remove 就 get,然后往里面一直塞东西,并且不 remove:

1
2
3
4
5
6
private ThreadLocal<List<SomeDTO>> tl = ThreadLocal.withInitial(ArrayList::new);

public void process(SomeDTO dto){
tl.get().add(dto);
//... do something
}

不过值得注意到是,这种循环往一个对象里塞东西并且不释放它的操作,tl 都没失去强引用,属于 bug 内存溢出而不是内存泄露。实际上,这段代码等价于:

1
2
3
4
5
6
private List<SomeDTO> list = new ArrayList();

public void process(SomeDTO dto){
list.add(dto);
//... do something
}

关 ThreadLocal 什么事啊喂?

言归正传,ThreadLocal 通过 ThreadLocalMap 中 Entry 的 key 的弱引用,与 key 为 null 时移除 entry 的机制,基本解决了【用户存在不当操作时】的内存泄漏问题。在【用户没有不当操作】(即 set/get from initial 与 remove 对偶)时,不会出现内存泄漏问题。

之所以称作基本解决,是因为在 ThrealLocalMap 的 set/getEntry/remove 方法不被调用时,key 为 null 的 entry 不会被处理,仍然持有对 value 的强引用。但这些 entry 的数量相当有限,并且只要线程存活且 ThreadLocalMap 被使用,就会被逐渐清理掉,即使逐渐占满了也会在 rehash 时全部处理掉,我理解这属于可控的内存溢出,甚至可以不被定义为内存泄露,而是一种延时 GC (当然确实是有对垃圾对象的强引用导致 FULL GC 都没法回收)。

依赖 ThreadLocal 兜底回收对执行性能影响其实不大,但确实引用了一些垃圾对象,影响内存。当然,处于规范与正确性,应当在 ThreadLocal 所引用的线程副本不被使用时调用 ThreadLocal#remove() 方法。